Execute multiple contract calls atomically#1702
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1702 +/- ##
==========================================
+ Coverage 76.90% 78.61% +1.70%
==========================================
Files 24 24
Lines 983 996 +13
Branches 186 188 +2
==========================================
+ Hits 756 783 +27
+ Misses 203 190 -13
+ Partials 24 23 -1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| external | ||
| onlySelf | ||
| { | ||
| CallContractsParams memory params = abi.decode(payload, (CallContractsParams)); |
There was a problem hiding this comment.
Params are decoded here for a second time, can probably send CallContractsParams to trySweepOnFailure.
| CallContractParams[] memory params = new CallContractParams[](1); | ||
| params[0] = | ||
| CallContractParams({target: address(0xdead), data: "", value: uint256(0)}); | ||
| bytes memory payload = abi.encode(params); |
There was a problem hiding this comment.
| bytes memory payload = abi.encode(params); | |
| CallContractsParams memory p = CallContractsParams({ | |
| calls: params, | |
| sweepRecipient: address(0), | |
| tokensToSweep: new address[](0) | |
| }); | |
| bytes memory payload = abi.encode(p); |
Since it should be a CallContractsParams struct, not CallContractParams[] array.
There was a problem hiding this comment.
Fixed this by wrapping the parameters in the CallContractsParams struct.
| } | ||
|
|
||
| // Sweep remaining assets when specified | ||
| function sweep(address recipient, address[] calldata tokens) external { |
There was a problem hiding this comment.
Can unrelated assets be swept unintentionally here?
There was a problem hiding this comment.
Typically, the sweep token list is constructed by the SDK and kept consistent with the transfer token list.
By the way, the sweep flow is optional and can be skipped by providing an empty address.
There was a problem hiding this comment.
So I like this PR in theory. But I would like to build this at a higher level. So here we are mimicking the Asset Claimer behaviour by using (maybe abusing) Transact and the fact that we can implement anything via arbitrary contract call, rather than sticking close to the XCM functionality and build it into our protocol directly.
For instance we should probably stick closer to the XCM spec, and by default always sweep assets on any failure. By default we should sweep assets to the locations Agent, however if SetHints{Claimer} is set then then we use that account. The asset list should be filled in by the on-chain code from the XCM ground truth instead of expecting the sdk to build it correctly offchain. Also if a user has an agent, JIT create the Agent if it does not exist. Jit creating an agent and paying more gas, but having funds recoverable is probably better than failing and having funds trapped in limbo.
If a user didnt specify any claimer and the funds got sent to the agent, we could use ClaimAsset instruction to get them back for instance.
There was a problem hiding this comment.
Thanks for the feedback, Alistair.
While sticking closer to the XCM spec for claims/sweeps is a good architectural direction for protocol-level asset recovery, CallContracts is actually a prerequisite for a safer, revamped contract interaction pattern we are moving towards.
Specifically, the target pattern is the standard DeFi "Approve-then-Call" flow executed atomically by the agent in a single transaction:
- Approve: The user agent calls the ERC20 token contract to approve the adapter (e.g. Across SpokePool/L1 Adaptor) to transfer assets on its behalf.
- Call: The user agent calls the adapter to execute the action (which pulls the approved assets).
Executing these two steps atomically via CallContracts resolves a major security risk: we no longer need to pre-fund shared adapters. Prefunding adapters across transaction boundaries has been the subject of multiple security reports due to the risk of front-running/drainage.
Having arbitrary atomic multi-calls gives us the flexibility to use safe patterns like this without relying on prefunding or custom protocol-level sweeping shims.
There was a problem hiding this comment.
This latest implementation is much simpler and I am for it.
# Conflicts: # contracts/src/Gateway.sol
…ls execution Simplifies the CallContracts command by removing the optional sweep-on-failure feature. Multiple contract calls will now execute atomically, reverting on the first failure and marking the command as failed, without attempt to recover and sweep remaining funds within the gateway loop. This eliminates significant complexity across the gateway, handlers, executor, types, events, and tests.
Wrap the CallContractParams array in the CallContractsParams struct before encoding to avoid decoding errors inside the gateway dispatcher during execution.
76e5671 to
e5aadd1
Compare
| for (uint256 i; i < len; ++i) { | ||
| bool success = Call.safeCall(params[i].target, params[i].data, params[i].value); | ||
| if (!success) { | ||
| revert(); |
There was a problem hiding this comment.
The revert does not forward inner contracts revert data. May help debugging and we should do the same for the singular callContract version.
There was a problem hiding this comment.
Implemented and bubbled up the revert data for both callContract and callContracts using assembly returndatacopy/revert. Added unit tests to GatewayV2.t.sol for verification.
f905d40
| ); | ||
| } | ||
|
|
||
| function testAgentCallContractsRevertedOnSecondFailure() public { |
There was a problem hiding this comment.
Instead of emitting an event with sayHello and testing for its non-existance can we test an actual balance update? Like a test which covers Transfer/Approve/Send like in the case with L2s.
There was a problem hiding this comment.
Added two integration tests to GatewayV2.t.sol to cover the Approve/Transfer/Send pattern using WETH and a mock adapter (MockAdapter):
- testAgentCallContractsTransferApproveSendSuccess: Verifies the success path, asserting that the agent's WETH balance is debited and the recipient's WETH balance is credited.
- testAgentCallContractsTransferApproveSendFailureRevertsAll: Verifies the failure path, asserting that when a subsequent contract call fails (e.g. attempting to send more than available), the entire state is reverted (meaning both agent and recipient WETH balances remain unchanged, and the allowance granted during the first step is reverted back to 0).
Move the duplicated returndata-bubbling assembly out of AgentExecutor.callContract/callContracts into a shared Call.bubbleRevert() helper, annotated memory-safe. No behavioral change. Also tidy whitespace in GatewayV2 tests.
| // Call an arbitrary solidity contract | ||
| uint8 constant CallContract = 5; | ||
| // Call multiple arbitrary solidity contracts | ||
| uint8 constant CallContracts = 6; |
There was a problem hiding this comment.
CallContract and CallContracts are too similar and easily confused. Could lead to issues.
How about calling itMultiCall?
There was a problem hiding this comment.
Good catch — renamed the whole plural family to `MultiCall`
CallContractscommand kind →MultiCallCallContractsParams→MultiCallParams- handler/executor
callContracts→multiCall
The singular CallContract / CallContractParams types are unchanged and still represent per-call execution. The on-the-wire encoding remains unchanged (uint8 = 6).
This was done in commit 089468b, and is also synced upstream in: paritytech/polkadot-sdk@533668e
Align the Rust outbound-queue primitives with the Gateway contract rename in Snowfork/snowbridge#1702, where the `CallContracts` command became `MultiCall`. - `Command::CallContracts` -> `Command::MultiCall` (kind 6 unchanged) - ABI struct `CallContractsParams` -> `MultiCallParams` - Update converter mapping and tests accordingly The single-call `CallContract` command (kind 5) is left untouched.
Align the outbound-queue primitives with the Gateway contract changes in Snowfork/snowbridge#1702 and make the XCM Transact payload self-describing. - Rename `Command::CallContracts` -> `Command::MultiCall` (kind 6 unchanged) and the ABI struct `CallContractsParams` -> `MultiCallParams`. - Rename `ContractCall` payload variants `V1`/`V2` -> `Single`/`Multi`, which read as a call mode rather than a protocol version. - Update the converter mapping, tests, and prdoc accordingly. The single-call `Command::CallContract` (kind 5) is left untouched. The encoding is unchanged; these are source-level renames.
Summary
Adds support to execute multiple contract calls atomically. Sub-calls are run in order and the entire command reverts on the first failure.
Behavior
CallContractsParamscontains a list of sub-calls (calls).Requires: paritytech/polkadot-sdk#12383